Fold repeated header lines per RFC 7230 §3.2.2#21
Conversation
header-lookup returns only the first value of the first case-insensitively-matching header key, so consumers of a header that may be split across multiple lines (or repeated) saw only the first line. Two shipped bugs followed: - transfer-encoding-chunked? split only the first Transfer-Encoding value on commas, so a request with `Transfer-Encoding: gzip` then `Transfer-Encoding: chunked` on separate lines was read as "gzip" and its chunked body was never dechunked (the bug web #43 worked around downstream). - Request.negotiate read only the first Accept line, so a request that split Accept across lines negotiated against that line alone. Add a private header-values helper that gathers the values of every case-insensitively-matching key, and route both accessors through it: transfer-encoding-chunked? now looks for a chunked token across all Transfer-Encoding values; negotiate folds all Accept values into one comma list before delegating to Accept.negotiate. header-lookup is unchanged for genuine singleton headers, and no public API was added.
There was a problem hiding this comment.
Build & Tests
Checked out claude/fold-repeated-headers at b250036e (base master, merge-base == origin/master). Built and ran test/http.carp on armhf: 178 passed, 0 failed — the 172 pre-existing plus all 6 new, including Request.negotiate folds an Accept split across multiple lines and Request.chunked? true when chunked is on a second Transfer-Encoding line. CI green on both runners. No CHANGELOG, correct — the repo has none.
Findings
No blocking findings. I checked the two things that matter most for a change like this — that the tests actually pin the bug, and that the helper doesn't widen the surface.
- Non-vacuity confirmed by A/B. I restored
http.carpfrommaster(old single-line behavior) while keeping the new tests, and reran: 175 passed, 3 failed. Exactly the three differential assertions fail against the old code —negotiate folds a multi-line Accept,chunked? true on a second Transfer-Encoding line, andchunked? folds case-differing Transfer-Encoding lines— while the three guards (negotiate still honors the first line,negotiate honors a single-line Accept,chunked? false for gzip/deflate) pass under both versions. So the tests genuinely exercise the new path and can fail; they aren't passing for free. - The fix is at the right layer and correctly scoped.
header-valuesisprivate+hidden, so no public accessor is added — it just folds every case-insensitively-matching key's values into one(Array String), and bothtransfer-encoding-chunked?andRequest.negotiateroute through it.header-lookupis untouched for genuine singletons. Empty cases are handled: no matching header →[]→chunked?false /negotiatetakes the accept-anything path. This is the root-cause fix for the split-Transfer-Encodingmiss thatweb#43 had to work around downstream. - One non-blocking observation. Values from keys that differ only in case are aggregated in
Mapiteration order, not header-line order. It doesn't matter forchunked?(a presence query). Fornegotiatethe", "-join feedsAccept.negotiate, which ranks by q-value, so cross-key ordering could only affect a tie-break between equal-q media types — RFC-acceptable. Repeated lines under the same key keep their order (the parser appends viaArray.push-back), which is the common case. Not worth changing. - carp-fmt / angler clean (CI confirms).
Verdict: merge
Correct, minimal, no new public surface, and backed by a test set I confirmed is non-vacuous. It's a draft by your choice — leaving it as a draft for you to steer; the code is ready to un-draft whenever you're happy.
Fold repeated header lines (RFC 7230 §3.2.2)
header-lookupreturns onlyArray.unsafe-firstof the first case-insensitively-matching header key, so every consumer of a header that a client may split across multiple lines (or repeat) saw just the first line. RFC 7230 §3.2.2 says a field whose value is a comma list may be split across multiple lines, and repeated lines must be treated as equivalent to one comma-joined line. Two shipped bugs followed from ignoring that:Transfer-Encodingwere never decoded.transfer-encoding-chunked?split only the firstTransfer-Encodingvalue on commas. A request withTransfer-Encoding: gzipthenTransfer-Encoding: chunkedon separate lines was read as justgzip→chunked?false → the chunked body was left un-dechunked. This is the root cause theweblibrary had to work around downstream in web #43.Acceptcontent negotiation ignored all but the firstAcceptline.Request.negotiateread only the firstAcceptline, so a request that splitAcceptacross two lines negotiated against the first line alone — a server offering only the media type named on the second line wrongly got no match.The fix (minimal, additive, no new public API)
A new private
header-valueshelper walks every key and, for each case-insensitively-matching one, appends its stored values into a single(Array String). Both accessors route through it:transfer-encoding-chunked?now splits each value on commas, lowercases/trims each token, and returns whether any token ischunked— a presence query across allTransfer-Encodingcodings. Single-line behavior is unchanged. (The "chunked must be last" validation nuance stays the caller's concern, e.g. web's gate — out of scope here.)Request.negotiatefolds allAcceptvalues into one", "-joined string before delegating toAccept.negotiate(whose parser already splits on commas) — exactly the §3.2.2 folding. NoAcceptheader → the existing empty-string path.header-lookupis left as-is for genuine singleton headers (Host,Content-Length, …). The helper isprivate+hidden: noheader-combined/header-valuesaccessor is exported, to avoid widening the public surface speculatively.The parser already appends same-case repeated lines into one key's array (
Map.update-with-default+Array.push-back, http.carp:339) and lands case-differing repeats in separate keys, so the fixtures exercise the real stored shape.Tests
Added to
test/http.carp, built from raw request strings viaRequest.parse(the existing pattern):multi-headerfixture (Accept: text/htmlthenAccept: application/json), offering only the second line's type now selectsapplication/json; the first-line type and a single-lineAcceptstill negotiate correctly.Transfer-Encoding: gzip/chunkedand a mixed-caseTransfer-Encoding/transfer-encodingvariant (the separate-keys path) are nowchunked?true; agzip/deflaterequest stays false.A/B verified: with the source fix stashed, the three differential assertions (multi-line negotiate, split same-case chunked, mixed-case chunked) fail against the old code, confirming they are non-vacuous; the guard assertions pass under both.
Verification
CARP_DIR=…/carp-lang carp -x test/http.carp→ 178 passed, 0 failed (172 pre-existing + 6 new).carp-fmt -candanglerclean on both changed files.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.